查看原文
其他

使用Unity新一代输入系统实现可配置摄像机

Unity Unity官方平台 2022-05-07

我们已经介绍过Unity新一代的输入系统。本文,我们将使用Unity 2019.2开发可以移动、缩放和旋转的可配置摄像机这种设计方法适用于不需要额外附带一个第一或第三人称摄像机,而是可以让游戏视角在场景自由移动的游戏。


摄像机的配置功能包括:

  • 摄像机角度

  • 最大和最小缩放设置

  • 默认缩放设置

  • 视线偏移(设定摄像机在Y轴上的观察位置)

  • 旋转速度


学习目标

  • 了解Unity新一代输入系统的重要概念。

  • 获得可根据游戏进行自定义的可配置摄像机。


学习准备

你需要有使用Unity的基础知识。本文将不会介绍基础知识,包括:游戏对象和组件的概念、何时调用Start方法等。


本文中的项目使用了Low Poly: Free Pack资源

https://assetstore.unity.com/packages/3d/environments/polyworks-free-pack-sample-58821


你可以复制本文的代码库,获得学习时用到的初始项目:

https://github.com/Unity-Technologies/InputSystem


安装新一代输入系统

Unity不断对输入系统进行全面的改进,以便使新一代输入系统更加强大而稳定,可以更好地适用于多种平台和设备配置。我们可以轻松配置该系统,使其能够处理多个本地玩家的输入。


请注意:新一代输入系统仍在不断完善开发中,处于预览阶段。


安装新一代输入系统,请通过资源包管理器安装Input System资源包,请按照以下步骤操作:

  • 依次点击Window > Package Manager

  • 选择Advanced > Show Preview Packages,显示预览版资源包。

  • 在搜索栏输入“Input System”,寻找该资源包。

  • 选中Input System资源包,单击Install按钮。


通用渲染管线等Unity特定功能需要使用旧的输入系统。因此,我们最好确保项目设置中的Active Input Handling属性设为Both这意味着我们可以在游戏中使用两种输入系统,但在本文中,我们只会使用新一代输入系统。


我们可以访问下面的设置,确定是否已经设置好该属性:依次点击Edit > Project Settings > Player > Configuration。


设置新一代输入系统

新一代输入系统比原有系统更为复杂。虽然初次学习难度更高,但会带来很好的回报。新系统更加强大稳定,在正确设置时,使用新系统所需的工作量会更少。

 

首先,我们要创建Input Controls输入控制资源,在项目窗口单击右键: 

  • 选择Create > Input Actions

  • 将新文件命名为PlayerInputMapping

  • 双击打开文件的编辑窗口。

 

配置输入时,有四个概念需要了解:

  • 控制方案(Control Scheme):用于设置必须满足的设备要求,从而使输入绑定变得可用。这是可选设置,我们可以把它保留原样,即不设定要求。

  • 动作导图(Action Maps):这是可以批量启用或禁用的动作组。

  • 动作(Action):能够分组到特定动作下的一组输入绑定,例如:“开火”或“移动”等动作。

  • 输入绑定(Input Bindings):用于指定要监视的设备输入,例如:手柄上的按键、鼠标按钮或键盘按键。

 

例如:在把动作设为多个输入绑定映射时,我们使用了“开火”动作,该动作会关联到手柄的特定按键,如果是键盘鼠标的设置方案,则会关联到鼠标右键。

 

动作导图、动作和输入绑定都有各自的属性。我们将在本文中详细介绍这些属性。


定义控制方式

输入方案将设计用于带有键盘和鼠标的设备,但如果需要,我们也可以轻松扩展到其它输入方式。总的而言,我们会有一个控制方案、一个动作导图、四个动作和五个输入绑定。


我们的设置如下图所示。



虽然上图看起来复杂,但创建该布局的方法其实很简单。打开PlayerInputMapping资源,创建一个新的动作导图

  • 单击Action Maps旁边的+图标,命名为Player。这会自动创建空白的Action部分和Input Binding节点。

  • Action部分重命名为Camera_Move。设置以下属性:Action Type设为Value。Control Type设为Vector 2。

 

我们使用2D Vector Composite绑定节点,而不是使用默认创建的节点。每次按下W、S、A或D键时,该节点会告诉输入系统发送2D Vector数值。目前该部分不会起到任何作用,在把动作关联到摄像机后,我们会使用到该数值。

 

  • 在空白的Binding部分单击右键,选择Delete,删除该节点

  • 右键单击Camera_Move动作,选择Add 2D Vector Composite,把新建的Binding部分命名为WASD

  • 选择名称有“Up: ”的部分,把Path设为W [Keyboard]

  • 对名称为Down、Left和Right的部分重复这些操作,把它们的Path设为对应的按键。

 

 

对方向键执行相同的操作。添加新的2D Vector Composite,命名为Arrows。设置每项映射到对应的方向键,现在我们会看到下图的设置。

 

 

我们现在需要设置剩余的动作和绑定:

  • 添加新动作,命名为Camera_Rotate

  • 把Action Type设为Value,Control Type设为Vector 2

  • 单击绑定部分,把它的Path设为Delta (Mouse)

 

接下来,我们要设置Camera_Rotate_Toggle的动作和绑定

  • 添加新动作,命名为Camera_Rotate_Toggle。 

  • Action Type设为Button

  • 单击绑定部分,把Path设为Right Button [Mouse]

 

最后,我们要设置Camera_Zoom动作和绑定:

  • 添加新动作,命名为Camera_Zoom

  • Action Type设为Value,Control Type设为Vector 2

  • 单击绑定部分,把Path设为Scroll [Mouse]

 

点击Save Asset保存改动。我们的导图画面如下图所示。


设置并移动摄像机

我们会使用两个游戏对象:CameraRig和Main Camera对象

  • 在场景中,创建空白游戏对象,命名为CameraRig

  • Main Camera对象设为CameraRig对象的子对象

  • 创建新脚本,命名为CameraController,把该脚本添加到CameraRig游戏对象。


CameraRig对象的作用是处理在场景中的移动和旋转。通过把这项功能作为单独的游戏对象来使用,我们可以随意在正向轴或右轴(Forward/Right axis)上移动,不必担心摄像机朝着哪个方向。


Main Camera对象会在开始时使用自定义属性来配置,确保它在世界空间中朝着正确方向。该对象也会处理缩放过程。


由于摄像机将是可配置的,因此我们首先定义可以在检视窗口设置的变量。

public class CameraController : MonoBehaviour

{
   [Header("Configurable Properties")]
   [Tooltip("This is the Y offset of our focal point. 0 Means we're looking at the ground.")]    public float LookOffset;
   [Tooltip("The angle that we want the camera to be at.")]    public float CameraAngle;
   [Tooltip("The default amount the player is zoomed into the game world.")]    public float DefaultZoom;
   [Tooltip("The most a player can zoom in to the game world.")]    public float ZoomMax;
   [Tooltip("The furthest point a player can zoom back from the game world.")]    public float ZoomMin;
   [Tooltip("How fast the camera rotates")]    public float RotationSpeed;
}


我们将把摄像机角度设为45度,在地上1米的位置进行观察,并且把缩放大小限制在2-10米。检视窗口中可以设置的所有属性如下图所示。



接下来,基于设定的属性,配置摄像机的起始点。添加以下全局变量和Start()方法到脚本中。

    //摄像机专用变量

    private Camera _actualCamera;

    private Vector3 _cameraPositionTarget;


    void Start()

    {

        //存储对Camera Rig的引用

        _actualCamera = GetComponentInChildren<Camera>();


        //基于CameraAngle属性设置摄像机的旋转

        _actualCamera.transform.rotation = Quaternion.AngleAxis(CameraAngle, Vector3.right);


        //基于Look Offset、Camera Angle和Default Zoom属性,设置摄像机的位置。这会确保我们观察正确的焦点。

        _cameraPositionTarget = (Vector3.up * LookOffset) + (Quaternion.AngleAxis(CameraAngle, Vector3.right) * Vector3.back) * DefaultZoom;

        _actualCamera.transform.position = _cameraPositionTarget;

    }


最好存储Main Camera游戏对象的引用,而不是调用Camera.main。在Unity没有存储Main Camera对象的引用时,直接调用Camera.main会产生明显的性能影响,并在每次调用时遍历场景层级和组件。


添加移动行为

添加移动行为到摄像机时,我们需要多个全局变量,LateUpdate()中的调用和新的OnMove()方法

    //移动变量

    private const float InternalMoveTargetSpeed = 8;

    private const float InternalMoveSpeed = 4;

    private Vector3 _moveTarget;

    private Vector3 _moveDirection;


    /// <summary>

    /// 基于玩家提供的输入,设置移动方向。

    /// </summary>

    public void OnMove(InputAction.CallbackContext context)

    {

        //读取输入系统发送的输入数值。

        Vector2 value = context.ReadValue<Vector2>();


        //把数值存为Vector3类型,确保在Z轴上移动Y输入。

        _moveDirection = new Vector3(value.x, 0, value.y);


        //增加摄像机的新移动目标位置。

        _moveTarget += (transform.forward * _moveDirection.z + transform.right * _moveDirection.x) * Time.fixedDeltaTime * InternalMoveTargetSpeed;

    }


    private void LateUpdate()

    {

        //把摄像机插补到新的移动目标位置。

        transform.position = Vector3.Lerp(transform.position, _moveTarget, Time.deltaTime * InternalMoveSpeed);

    }


OnMove()会通过调用context.ReadValue<Vector2>()来存储玩家输入数值。由于在使用Vector 2合成绑定,根据不同输入,我们会看到相应的X值和Y值:

  • Up: 0, 1

  • Down: 0, -1

  • Right: 1, 0

  • Left: -1, 0


将输入系统关联到代码

有了初始代码后,我们要进行测试运行,查看它的使用效果。为此,我们需要告诉输入系统什么时候发送动作。


我们添加Player Input组件到场景的游戏对象:

  • 创建新游戏对象,命名为GameManager

  • 单击Add Component按钮,搜索Player Input组件。

  • 设置以下属性:

    Actions:设为刚刚配置的PlayerInputMapping资源。 

    Default Map:设为Player。

    Behavior:设为Invoke Unity Events。 

  • 展开Events和Player部分

  • Camera_Move事件下,引用CameraRig对象,把事件设为CameraController.OnMove()。 



我们现在可以进入运行模式,然后移动摄像机。


虽然我们使用的是Invoke Unity Events,即调用Unity事件通知行为,但也要了解不同选项及其作用:

  • Send Messages(发送信息):该选项会发送信息到该对象上的所有脚本。

  • Broadcast Messages(广播信息):除了把输入信息发送到同一对象上的组件外,该选项还会把信息发送到子对象层级。

  • Invoke Unity Events(调用Unity事件):该选项会为每种类型的信息调用UnityEvent。UI可用于设置回调方法。

  • Invoke C Sharp Events(调用C#事件):该选项类似Invoke Unity Events,但是会调用C#事件,这些事件必须通过脚本的回调来注册。


了解不同事件类型及其设置方式:

https://docs.unity3d.com/Packages/com.unity.inputsystem@1.0/manual/Components.html


修复摄像机移动

我们还未实现想要的行为,玩家应该能够按住按键,观察摄像机朝着相应方向持续移动的过程。


输入系统只会在按键按下时发送一次事件,输入系统没有简单的方法来监视按住按键的行为,因此我们需要自己解决该问题。


输入绑定有交互的概念,其中一项交互叫“Hold”。这项交互的作用是在按住按键的特定持续时间后触发动作,而在按住按钮时,它不会持续触发动作。


了解交互的更多内容:

https://docs.unity3d.com/Packages/com.unity.inputsystem@1.0/manual/Interactions.html#predefined-interactions


这个问题的解决方法很简单,我们只要把最后一行代码从OnMove()移动到FixedUpdate()中


我们的代码如下所示:

    public void OnMove(InputAction.CallbackContext context)

    {

        //读取输入系统发送的输入数值。

        Vector2 value = context.ReadValue<Vector2>();


        //把数值存为Vector3类型,确保在Z轴上移动Y输入。

        _moveDirection = new Vector3(value.x, 0, value.y);

    }


    private void FixedUpdate()

    {

        //根据移动方向设置移动目标位置,该操作必须在此完成,因为输入系统没有逻辑来计算按住输入按键的事件。

        _moveTarget += (transform.forward * _moveDirection.z + transform.right * _moveDirection.x) * Time.fixedDeltaTime * InternalMoveTargetSpeed;

    }


进入运行模式后,我们的摄像机移动过程变得非常流畅,而且可以很好地处理方向变化,如下图所示。


添加缩放行为

添加缩放功能时,我们需要调整代码,使代码更简洁。这是因为摄像机需要能够根据当前缩放值,重新计算新的Y值和Z值。


首先,我们添加下列全局变量和UpdateCameraTarget()方法

    //缩放变量

    private float _currentZoomAmount;

    public float CurrentZoom

    {

        get => _currentZoomAmount;

        private set

        {

            _currentZoomAmount = value;

            UpdateCameraTarget();

        }

    }

    private float _internalZoomSpeed = 4;


    /// <summary>

    /// 根据多个属性计算新的位置

    /// </summary>

    private void UpdateCameraTarget()

    {

        _cameraPositionTarget = (Vector3.up * LookOffset) + (Quaternion.AngleAxis(CameraAngle, Vector3.right) * Vector3.back) * _currentZoomAmount;

    }


我们可以更新Start()方法,把CurrentZoom设为DefaultZoom的数值,而不是让脚本计算数值,代码如下所示。

    void Start()

    {

        //存储对Camera Rig的引用

        _actualCamera = GetComponentInChildren<Camera>();


        //基于CameraAngle属性设置摄像机的旋转。

        _actualCamera.transform.rotation = Quaternion.AngleAxis(CameraAngle, Vector3.right);


        //基于Look Offset、Camera Angle和Default Zoom属性,设置摄像机的位置。这会确保我们观察正确的焦点。

        CurrentZoom = DefaultZoom;

        _actualCamera.transform.position = _cameraPositionTarget;

    }


接下来,添加新的OnZoom()方法,更新LateUpdate()方法,使其基于新的缩放系数来移动_actualCamera的本地位置。

        /// <summary>

        /// 设置缩小和放大的逻辑。限制为最小值和最大值。

        /// </summary>

        /// <param name="context"></param>

        public void OnZoom(InputAction.CallbackContext context)

        {

            if (context.phase != InputActionPhase.Performed)

            {

                return;

            }


            // 根据滚动方向调整当前缩放值,该值的大小限制为最大值和最小值之间。

            CurrentZoom = Mathf.Clamp(_currentZoomAmount - context.ReadValue<Vector2>().y, ZoomMax, ZoomMin);

        }


    private void LateUpdate()

    {

        //把摄像机插补到新的移动目标位置。

        transform.position = Vector3.Lerp(transform.position, _moveTarget, Time.deltaTime * InternalMoveSpeed);


        //根据新的缩放系数,移动_actualCamera的本地位置。

        _actualCamera.transform.localPosition = Vector3.Lerp(_actualCamera.transform.localPosition, _cameraPositionTarget, Time.deltaTime * _internalZoomSpeed);

    }       


根据输入阶段的不同,事件的多个实例会以不同的状态发送。对于OnZoom(),如果处于Performed状态,我们只想处理读取数值的部分,因为这会确保我们不会得到扰乱逻辑的数值。如果没有这项检查,我们会在Started状态和Canceled状态处理两个以上的调用。

 

了解输入动作状态的更多内容:

https://docs.unity3d.com/Packages/com.unity.inputsystem@1.0/api/UnityEngine.InputSystem.InputActionPhase.html

 

现在我们要进行测试。通过关联Move事件的方法,把逻辑关联到输入系统:

  • Camera_Zoom事件下,引用CameraController游戏对象,把事件设为CameraController.OnZoom

  • 运行项目,然后滚动鼠标滚轮。

 

我们发现,缩放值会在设置的最大缩放值和最小缩放值之间切换,而不是逐渐递增。这是因为滚动鼠标滚轮时,发送的输入值太大,每次滚动发出的Vector 2值都会是(0, 120)或(0, -120)。

 

为了实现缓慢地逐渐递增,我们的逻辑需要把数值归一化为(0, 1)或(0, -1)。为此,我们进行以下操作:

  • 打开PlayerInputMapping资源,选中Camera_Zoom动作下的Scroll [Mouse]绑定

  • 在属性面板,单击Processors部分下的+按钮,选择Normalize Vector 2

  • 保存文件

 

我们有许多实用的处理器可以应用到动作、控制和绑定,包括:为手柄输入指定空白区域数值。

 

了解更多不同事件类型及设置方法的内容:

https://docs.unity3d.com/Packages/com.unity.inputsystem@1.0/manual/Processors.html



 如下图所示,现在我们会看到流畅的滚动行为。


添加旋转行为

摄像机的旋转过程由两步组成。首先,我们需要知道玩家是否在让摄像机旋转。这一步会监视玩家是否按下鼠标右键。如果按下了鼠标右键,我们会获取鼠标位置,告诉游戏应该朝什么方向旋转。


监视按钮操作非常简单,我们只需要读取某个浮点值是0(关闭)还是1(启用)即可。为此,我们要给脚本添加以下全局变量和OnRotateToggle()方法。

    //旋转变量

    private bool _rightMouseDown = false;

    private const float InternalRotationSpeed = 4;

    private Quaternion _rotationTarget;

    private Vector2 _mouseDelta;


    /// <summary>

    /// 设置玩家是否按下鼠标右键。

    /// </summary>

    /// <param name="context"></param>

    public void OnRotateToggle(InputAction.CallbackContext context)

    {

        _rightMouseDown = context.ReadValue<float>() == 1;

    }


给脚本添加OnRotate()方法,该方法会在按下鼠标右键时,旋转摄像机。

    /// <summary>

    /// 如果玩家按下鼠标右键并移动鼠标,则设置旋转目标的Quaternion类数值。

    /// </summary>

    /// <param name="context"></param>

    public void OnRotate(InputAction.CallbackContext context)

    {

        // 如果按下鼠标右键,我们会读取鼠标的_mouseDelta值。如果没有按下,我们会清零该值。

        // 请注意:清零_mouseDelta值会避免在玩家朝某个方向快速移动鼠标时,发生“Death Spin”情况。

        _mouseDelta = _rightMouseDown ? context.ReadValue<Vector2>() : Vector2.zero;


        _rotationTarget *= Quaternion.AngleAxis(_mouseDelta.x * Time.deltaTime * RotationSpeed, Vector3.up);

    }


最后,给LateUpdate()方法和Start()方法添加逻辑,让它们旋转摄像机。

    void Start()

    {

         //存储对Camera Rig的引用

        _actualCamera = GetComponentInChildren<Camera>();


        //基于CameraAngle属性设置摄像机的旋转。

        _actualCamera.transform.rotation = Quaternion.AngleAxis(CameraAngle, Vector3.right);


        //基于Look Offset、Camera Angle和Default Zoom属性,设置摄像机的位置。这会确保我们观察正确的焦点。

        CurrentZoom = DefaultZoom;

        _actualCamera.transform.position = _cameraPositionTarget;


        //设置初始旋转值。

        _rotationTarget = transform.rotation;

    }


    private void LateUpdate()

    {

        //把Camera Rig插值到新的移动目标位置

        transform.position = Vector3.Lerp(transform.position, _moveTarget, Time.deltaTime * InternalMoveSpeed);


        //根据新的缩放系数,移动_actualCamera的本地位置。

        _actualCamera.transform.localPosition = Vector3.Lerp(_actualCamera.transform.localPosition, _cameraPositionTarget, Time.deltaTime * _internalZoomSpeed);


       //根据新的目标,对Camera Rig的旋转进行球面插值。

        transform.rotation = Quaternion.Slerp(transform.rotation, _rotationTarget, Time.deltaTime * InternalRotationSpeed);

    }


根据新的方法,把逻辑关联到输入系统:

  • Camera_Rotate事件下,引用CameraController游戏对象,把事件设为CameraController.OnRotate

  • Camera_Rotate_Toggle事件下,引用CameraController游戏对象,把事件设为CameraController.OnRotateToggle

  • 运行项目,按住鼠标右键并移动鼠标。


虽然此时看似正常运行,但我们为了更新旋转状态使用了过多的不必要调用。为了更好了解情况,我们要知道OnRotate()输入事件每帧会进行多少次调用。


我们会加入一些临时代码来展示次数。

// 创建新的全局变量。

private float _eventCounter;


// 添加以下代码到OnRotate方法的结尾。

// 该代码会在每次事件调用时,递增eventCounter值。

eventCounter += _rightMouseDown ? 1 : 0;


// 添加下面代码到LateUpdate方法的结尾。

// 由于LateUpdate方法会在每帧运行一次,因此它会记录事件在每帧调用的总次数,然后在下次检查时清空结果。

Debug.Log(eventCounter);

eventCounter = 0;


在运行代码并旋转摄像机时,我们可以看到每一帧都多次触发OnRotate()事件。



此外在一帧中,随着每次事件触发而发送的鼠标增量会逐渐增长。考虑到这一点,我们最好每帧应用一次最终增量值。


为此,把_rotationTarget *= Quaternion.AngleAxis(_mouseDelta.x * Time.deltaTime * RotationSpeed, Vector3.up); 代码从OnRotate()方法移动到LateUpdate()方法中。

 private void LateUpdate()

        {          

            //把Camera Rig插值到新的移动目标位置

            transform.position = Vector3.Lerp(transform.position, _moveTarget, Time.deltaTime * InternalMoveSpeed);


            //根据新的缩放系数,移动_actualCamera的本地位置。

            _actualCamera.transform.localPosition = Vector3.Lerp(_actualCamera.transform.localPosition, _cameraPositionTarget, Time.deltaTime * _internalZoomSpeed);


            //根据鼠标增量位置和旋转速度,设置目标旋转。

            _rotationTarget *= Quaternion.AngleAxis(_mouseDelta.x * Time.deltaTime * RotationSpeed, Vector3.up);


            //根据新的目标,对Camera Rig的旋转进行球面插值。

            transform.rotation = Quaternion.Slerp(transform.rotation, _rotationTarget, Time.deltaTime * InternalRotationSpeed);

        }


大功告成,我们现在拥有了功能完善的摄像机,它能够在场景中通过使用新一代输入系统进行旋转、缩放和移动。们可以在检视窗口通过调整RotationSpeed变量来增加速度。


小结

通过本文的学习,我们希望开发者能够熟练掌握好Unity新一代的输入系统。如果你有任何反馈,请访问Unity官方论坛:

https://forum.unity.com/forums/new-input-system.103


下载Unity Connect APP,请点击此处 观看更多Unity官方精彩视频,请关注“Unity官方”B站账户。


你可以访问Unity答疑专区留下你的问题,Unity社区和官方团队帮你解答:

Connect.unity.com/g/discussion


推荐阅读

Unity新一代输入系统介绍

Unity 2019.3中的物理功能更新

2020年Unity Pro专业版和Plus加强版订阅价格将调整

使用Preset预设功能改善你的工作流程

Unity Labs新一代AR/MR工具:Project MARS

新一代打砖块游戏《星际砖块》开发分享

使用Unity创作印第安纳·琼斯的教室环境

创作类《塞尔达传说:旷野之息》风格的水着色器


直播课程

10月30日的直播课程由Unity技术经理成亮为你进行Unity 2019.3最新2D功能实例讲解。了解详情.....


直播时间:10月30日 20:00-21:00 (明晚!!! )

直播地址:

https://connect.unity.com/i/a8a694a6-4ffc-4f70-9db2-58ca02f98826



觉得实用,点个“在看”吧

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存